Skip to content

fix(client)!: oauth.applications.delete resolves on the zero-byte 200 its route answers, instead of rejecting on every successful delete - #15675

Merged
os-litant merged 7 commits into
mainfrom
claude/issue-15451-oauth-delete-void
Sep 6, 2026
Merged

fix(client)!: oauth.applications.delete resolves on the zero-byte 200 its route answers, instead of rejecting on every successful delete#15675
os-litant merged 7 commits into
mainfrom
claude/issue-15451-oauth-delete-void

Conversation

@os-litant

@os-litant os-litant commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Fixes #15451

ObjectStackClient.oauth.applications.delete ended return res.json() on a route that answers HTTP 200 with a zero-byte body, so it rejected with SyntaxError: Unexpected end of JSON input on every successful delete — after the row had already been committed away server-side. The method had no success path a caller could observe, and the obvious recovery (retry) failed differently, with the route's 404 not_found.

The fifth and last method of the oauth.* family, and the one #14312 / PR #15445 deliberately could not close: that ruling fenced the card to narrowing published return types, and no declared return type can be true while the res.json() call stands.

Re-driven, not inherited

The card's premise was re-measured on this branch rather than taken from the card, at 3fd460ee402 (the binding commit). Real betterAuth + real @better-auth/oauth-provider over the real ObjectQL adapter on real SQLite, a real signed-up user and a real session, driven through the real ObjectStackClient with only the socket stood in for (@objectstack/verify's in-process app), against the showcase stack:

REPRO register      -> client_id + client_secret          (positive control)
REPRO get           -> 200                                (positive control)
REPRO getPublic     -> 200, the 7-column projection       (positive control)

REPRO WIRE delete-client -> status 200 | bytes 0
                          | content-type "application/json"
                          | content-length null

BEFORE  REPRO CLIENT delete       -> REJECTED: SyntaxError | Unexpected end of JSON input
        REPRO after-delete get    -> 404 {"error_description":"client not found","error":"not_found"}
        REPRO CLIENT delete RETRY -> REJECTED: Error | not_found

AFTER   REPRO CLIENT delete       -> RESOLVED | value = undefined
        REPRO after-delete get    -> 404 (unchanged — the row was always really gone)
        REPRO CLIENT delete RETRY -> REJECTED: Error | not_found (unchanged)

The three sibling methods answering normally on the same run are the harness's positive control: this is not a probe that failed to reach the route. The 404 after the client threw confirms the nastiest part of the card — the caller sees failure for a completed destructive operation, and the natural retry then produces a second, differently-shaped failure.

The harness was a throwaway; it is not in this diff. Everything it proved is pinned below at a level that does not need a stack boot.

The two open questions the card handed over, answered by measurement

1. What should the method resolve to? void. The route does distinguish "deleted" from "was already gone" — but on the error channel: a client that is not there answers 404 { error: 'not_found' }, which this.fetch already raises as a throw before any success value exists. The 200 answer carries zero bytes and therefore zero information, so a synthesised { deleted: true } would be a shape the wire never sends and strictly less informative than the 404 the caller already receives. The vendor agrees from its own side: StrictEndpoint('/oauth2/delete-client', ..., void).

2. How should the emptiness be detected? By reading the body. Both shortcuts were measured against the real route and both are unusable:

candidate measured verdict
res.status === 204 — the spelling five other delete surfaces in this file use the status is 200 never fires
content-length === '0' the header is absent (null), not zero never fires; would look like a fix while leaving the defect in place
read the body '' on success the only thing that answers

What it costs the day the route grows a payload: a non-empty body is still parsed, and its failure still thrown — so a malformed response stays loud and the only behaviour this change moves is the zero-byte case. A well-formed payload is validated and discarded under the declared void; surfacing it would be a deliberate, reviewable widening of the return type, never a silent change of shape. The parse is therefore load-bearing rather than dead code, and it is pinned as such.

What is in the diff

path why
packages/client/src/index.ts the binding, plus a docblock recording the measurement and both refuted shortcuts
packages/client/exported-any-returns.json the method's entry deleted with the binding — the ledger is shrink-only. 36 open sites become 35, and its last oauth.* entry is gone
packages/client/src/oauth-applications-delete.test.ts new: the runtime half. 7 pins
packages/client/src/return-type-precision.test.ts the toEqualTypeOf(any) line PR #15445 left behind "to be replaced when the open decision lands" is replaced by returnTypePrecisionPins15451
.changeset/client-oauth-delete-zero-byte-200.md minor, **BREAKING**, carrying the ADR-0087 disposition marker adr-0087: registered client-oauth-applications-delete-void
packages/spec/src/migrations/entries/semantic/18.client-oauth-applications-delete-void.ts new: the semantic ledger entry the marker names. Major 18, the open window — PROTOCOL_VERSION is 17.0.0
packages/spec/src/migrations/registry.ts regenerated by pnpm --filter @objectstack/spec run gen:migration-registry (187 semantic, 161 retired-key, 116 retired-def). Never hand-edited; a re-run at the delivered head is a no-op, which is how the entry is proven to be indexed

Both halves the triage comment asked for are here: the ledger entry goes in the same PR as the binding, and there is a pin that the method resolves on a zero-byte 200 — so restoring res.json() "for symmetry with its three siblings" cannot happen silently.

Its positive control is carried too: automation.trigger, bound by PR #13082, is absent from the ledger, so the zero I am reading is a real absence and not a mis-read of the file.

The BREAKING question, answered explicitly

BREAKING on two independent axes, and it makes a published method usable for the first time:

  • type — the declared return moves off an erased any onto void. A read like (await ...delete(id)).deleted stops compiling (TS2339). ⚠️ Worth stating plainly: that code has never executed, because the promise never resolved. The compile break can only reach code that was already dead.
  • runtime — a caller's catch stops firing on success. try { await ...delete(id) } catch {} still compiles and still runs, but its catch block was executing on every successful delete and now executes only on a real failure. Any workaround inside it is now inert.

Dropping the **BREAKING** token is not an available exit: ADR-0087's 2026-08-30 addendum closes it for this class in as many words.

ADR-0087 disposition — settled by the maintainer, and the gate is green

This section replaces the "one gate is RED on purpose" block this PR carried until 2026-09-06. That block is no longer true: the disposition question it raised has been ruled on, and check:adr-0087-registration now exits 0.

The ruling — D, on #15674 (maintainer, recorded by the director seat 2026-09-05, comment 5549251533): this class routes through ADR-0087 registered. A published SDK return-shape move registers in the migration ledger, which already carries @objectstack/client entries of exactly this class — entries/semantic/17.client-delete-result-success.ts and 18.client-meta-reset-result-reset.ts — and which PR #14526 drove end to end for #13079. The registered marker names a ledger id, not a symbol, so the bare-identifier reference-reader defect (#15627) cannot block it. The sixth category's D8 text is not widened: "the only thing that moved is a type annotation" stays accurate as written, and a diff that also moves the body is not a type-surface-only change — it now has a named home instead.

What was done, exactly as the ruling prescribes:

  1. packages/spec/src/migrations/entries/semantic/18.client-oauth-applications-delete-void.ts — one SemanticMigration, shaped on the two sibling entries the ruling names. surface carries both halves of what a caller observes (declared return any to void; settle behaviour reject-on-every-successful-delete to resolve), replacement records that there is nothing to move a read to and that the migration is on the settle path, reason gives the argument for registering rather than exempting, and acceptanceCriteria carries the by-hand catch sweep plus the measured populations. The card, PR, ruling and precedent anchors live in a source comment above the type import rather than in the strings — the strings are projected into spec-changes.json and docs/protocol-upgrade-guide.md, read by consumers who cannot resolve this repo's issue numbers. That placement follows 18.client-envelope-convergence-analytics-automation.ts, which does the same.
  2. packages/spec/src/migrations/registry.ts — regenerated, never hand-edited.
  3. The changeset's clearly-labelled non-claim note became the real marker, adr-0087: registered client-oauth-applications-delete-void. **BREAKING** and minor are unchanged.

Why D and not the routes this PR originally reasoned toward — kept because they are the argument for D, not because either is still live:

This PR still touches no governed surface. docs/adr/**, .claude/**, skills/**, AGENTS.md and CLAUDE.md are all absent from the diff — the ruling routes around them by design. The cross-lane touch on packages/spec is the one the ruling authorises in as many words, on the shape #14996 to #14526 used; the domain:spec seat is informed by that comment rather than asked to re-approve. Clause ② yes is already declared on the card, and packages/spec/src/** is the mechanical floor path leg, so the declaration only gets stronger.

Verification — all at 459d7037a58

origin/main was merged in at fc3fb7c4619 (merge only — never rebased, amended or force-pushed) and every number below was re-measured after that merge commit, at the head this PR now delivers. The readings the earlier body carried at 3fd460ee402 are superseded except where a head is named explicitly.

Derived gate unionnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run at 459d7037a58. Change set: exactly the 7 paths in the table above, three-dot against merge base fc3fb7c46.

Reconciliation — 76 famil(ies): this card's WHOLE runnable answer
  65 named by PATH + 7 by change KIND + 7 DECLARED whole-tree, 3 reached both ways

harvest --commands        76 lines   (asserted against the Reconciliation line)
ran                       76 of 76   exit 0 on all 76

The one family that did not measure on the first pass was converted into a real measurement rather than reported as a pass:

check:type-check-debt   first run exit 3 — FATAL ERROR: Ineffective mark-compacts near
                        heap limit ... JavaScript heap out of memory, under
                        NODE_OPTIONS=--max-old-space-size=4096. The gate's own text:
                        "This is NOT a pass and NOT a finding: nothing was measured".
                        Re-run at 8192 MB under the shared verify lock:
                        "check-type-check-coverage --re-measure: OK — 12 ledger
                        entr(ies) re-measured in 93.2s, 140 raw tsc error(s) total,
                        none above its recorded number."   exit 0

Two verdicts carry a scoped NOT MEASURED line that is a property of the local invocation, not of this diff, and neither is read as a clearance:

  • check-changeset-no-major --base origin/main✓ This diff introduces no major bump, plus LEVEL AXIS: NOT MEASURED — no clause-② declaration was readable for this PR (no pull_request payload exists outside CI). CI reads the declaration.
  • check:entry-nameability446 call probes across 17 public entries, 0 new unnameable structural mentions, plus a pre-existing NOT MEASURED: no callable export on @objectstack/spec/qa, which is a fact about that entry and not about this change.

Outside the 76, and run separately because their silence is a fact about a list rather than about these paths: the 5 artifact-roster families whose roster sits in a directory one of these paths is in (check-changeset-fixed, check:meta-url-spelling, check:authz-resolver, check:error-code-casing, check:filter-alias-parity) — all exit 0 — and the 10 declared-WIDE-population families CI runs on every PR (check:init-service-contract, check:live-db-isolation, check:meta-type-normalized, check:optional-error-sink, check:resume-authority-declared, check:runner-env-posture, check:settings-bind-window, check:startup-registry-verdict, check:verify-stand-in, check:wildcard-fallthrough) — all exit 0.

The gate this PR was blocked on, quoted from its own verdict lines at 459d7037a58:

✓ check-adr-0087-registration --self-test: 332 assertions over real temp git repos
  (real scan()/assertInputs() path)
✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an
  ADR-0087 disposition.
    .changeset/client-oauth-delete-zero-byte-200.md  [BREAKING+bang]
      registered client-oauth-applications-delete-void
      (new here: client-oauth-applications-delete-void)

Ledger regeneration is a no-op at the delivered head, which is the proof that the entry is indexed rather than merely present:

pnpm --filter @objectstack/spec run gen:migration-registry
  ✓ wrote src/migrations/registry.ts (187 semantic, 161 retired-key, 116 retired-def)
git status --porcelain   (empty)

Package suites and the ratchet, re-run at the delivered head after rebuilding the dependency closure (pnpm --workspace-concurrency=2 --filter '@objectstack/client^...' build, exit 0) — the merge moved @objectstack/objectql, @objectstack/runtime and @objectstack/service-analytics, all of which @objectstack/client depends on, so a reading off the pre-merge dist would have been a reading about a different tree:

pnpm --filter @objectstack/client test          Test Files 34 passed (34) · Tests 444 passed (444)   exit 0
pnpm --filter @objectstack/client typecheck     tsc --noEmit + check:test-typecheck                  exit 0
pnpm --filter @objectstack/client check:exported-any-returns
        "no NEW exported callable of @objectstack/client resolves to `any`: 317 callables
         reached (52 caller-supplied generics, not counted as erasure), 35 ledgered
         site(s) still open."                                                                        exit 0
pnpm --filter @objectstack/spec  exec vitest run
        Test Files 482 passed (482) · Tests 13102 passed (13102)                                     exit 0
pnpm --filter @objectstack/spec  typecheck
        "@objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json"              exit 0
pnpm lint   (eslint . --no-inline-config, WHOLE repo, NOT narrowed)                                  exit 0

packages/client/exported-any-returns.json is shrink-only and it shrank: 36 open sites to 35, its last oauth.* entry gone. The 35 above is that same number read back at the delivered head.

Every exit code above was captured after redirection to a file, never through a pipe.

Ablation — measured at 3fd460ee402, on the binding commit

Stamped with its own head deliberately: this leg was run against the binding before the ledger entry and the origin/main merge existed, and it is not re-attributed to the delivered head.

Direction predicted in writing first: reverting index.ts alone, with the guards kept, should turn 4 of the 7 runtime pins red, break typecheck, and turn the ledger gate red in the unlisted direction. Observed, all three:

mutation on disk   removed-text (fixed signature) 0 · removed-text (marker) 0
                   injected-text (pre-fix res.json) 1 · blob differs from HEAD
mutation in dist   ablation-dist-preflight --absent -> marker absent from all 6 built files
PIN SUITE          exit 1 — Tests 4 failed | 3 passed (7)
TYPECHECK          exit 1 — 4 type errors in return-type-precision.test.ts
                   (the void equality, plus three now-unused ts-expect-error, TS2578)
LEDGER GATE        exit 1 — "ObjectStackClient.oauth.applications.delete resolves to
                   Promise of any and is not ledgered"

The three pins that stayed green are the ones that were green in both states and are labelled as such in the file: the malformed body, the whitespace-only body, and the 404. Restore was under an EXIT INT TERM trap using absolute paths, then proven three ways — blob-hash equality with the HEAD blob, an empty git diff HEAD, and an empty whole-tree git status --porcelain — and dist was rebuilt afterwards and re-checked for the marker, because the restore leg only restores source.

Sequencing

packages/client/src/index.ts is the #12104 family's measured hard serial. It was held by nobody at the ref this branch was cut from, and nothing that landed on origin/main in the meantime touched it (re-checked at both merges). Cards #15447 and #12104 queue behind this file.


🤖 Generated with Claude Code

https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N

…ts route answers

`ObjectStackClient.oauth.applications.delete` ended `return res.json()` on a
route that answers HTTP 200 with a ZERO-BYTE body, so it rejected with
`SyntaxError: Unexpected end of JSON input` on EVERY successful delete — after
the row had already been committed away server-side. The method had no success
path a caller could observe, and the obvious recovery (retry) failed
DIFFERENTLY, with the route's 404 `not_found`.

Measured end to end, not inherited: real betterAuth + real oauthProvider over
the real ObjectQL adapter, a real signed-up user and session, driven through the
real client with only the socket stood in for.

    POST /oauth2/delete-client -> 200 · 0 bytes · content-type application/json
                                  · NO content-length header
    through the client, before -> REJECTED: SyntaxError
    the row, server-side       -> ALREADY GONE (get-client answers 404)
    through the client, after  -> RESOLVED | undefined

Emptiness is detected by READING the body. Both shortcuts were measured and both
are unusable here: the status is 200, not the 204 five other delete surfaces in
this file key off, and the response carries no `content-length` header at all.

A non-empty body is still parsed and its failure still thrown, so the ONLY
behaviour that moves is the zero-byte case. `void` is the wire fact: "deleted"
and "was already gone" are distinguished on the ERROR channel (404 `not_found`,
raised by `this.fetch` before any success value exists), so a synthesised
`{ deleted: true }` would be a shape the wire never sends.

`exported-any-returns.json` loses this method's entry in the same change — the
ledger is shrink-only, so the entry goes WITH the binding. Its last `oauth.*`
entry is now gone; 35 sites remain open. The `toEqualTypeOf<any>()` pin PR
#15445 left behind for exactly this moment is replaced by
`returnTypePrecisionPins15451`, and the reject/resolve flip — which no
compile-time assertion can observe — is pinned in a new runtime suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
….delete`

`**BREAKING**` on two independent axes: the declared return moves off an erased
`any` onto `void` (a compile break, though only for reads of a value the promise
never produced), and the runtime flips from always-rejecting to resolving, so a
caller's `catch` stops firing on success.

⚠️ The ADR-0087 disposition is NOT claimed, and the gate is expected to red on
this changeset until a maintainer settles it. Both legs were measured rather
than guessed:

  type-surface-only          REFUSED at predicate 4. A reference is a bare
                             identifier resolved to the FIRST same-named
                             definition, and index.ts declares TEN members named
                             `delete`; the first (line 2397) is unannotated at
                             both revs, so the gate reports "still UNANNOTATED"
                             about a member this diff never touched. Issue
                             #15627, filed off PR #15445 where the same
                             ambiguity cost the `get` member its place in the
                             marker — here it blocks the only member there is.

  no-migration-prescription  mechanically ACCEPTED, and deliberately not taken.
                             ADR-0087's D7 records that #8277 held this
                             exemption on a detector MISS rather than a positive
                             finding, and names that as the pattern the sixth
                             category exists to stop. Taking it here, with the
                             measurement in hand, would repeat it knowingly.

Dropping the `**BREAKING**` token is the third exit and ADR-0087's addendum
closes it for this class in as many words. So the honest state is a loud red on
one gate with the reasoning written down, rather than a green held by a category
that does not describe this diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/client, @objectstack/spec, touching 2 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/client/exported-any-returns.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via not_found (literal, a string literal in oauth))
  • content/docs/api/error-catalog.mdx (via not_found (literal, a string literal in oauth))
  • content/docs/api/index.mdx (via not_found (literal, a string literal in oauth))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via not_found (literal, a string literal in oauth))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/client/exported-any-returns.json) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 132 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json dacb73f4fc3cd3b3ad55d766047b1e4d6f2c521apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0a1ccafb74726260da0453efb58b540bd0f8533d — the merge of head 38d66fea35d6af7038632a5e013516cd415b6e8a into base dacb73f4fc3cd3b3ad55d766047b1e4d6f2c521a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0a1ccafb74726260da0453efb58b540bd0f8533d && git checkout 0a1ccafb74726260da0453efb58b540bd0f8533d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dacb73f4fc3cd3b3ad55d766047b1e4d6f2c521a 38d66fea35d6af7038632a5e013516cd415b6e8a && git checkout -B drift-repro dacb73f4fc3cd3b3ad55d766047b1e4d6f2c521a && git merge --no-ff 38d66fea35d6af7038632a5e013516cd415b6e8a

node scripts/docs-audit/affected-docs.mjs --json dacb73f4fc3cd3b3ad55d766047b1e4d6f2c521a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs dacb73f4fc3cd3b3ad55d766047b1e4d6f2c521a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Seat status — the code is done; the block is a governed-surface decision, escalated

⛔ Not flipped, not enqueued. needs:contract-review is now hung on card #15451 and this PR together, in one round, now that the diff exists — 不预挂 satisfied. Clause ② is YES, re-declared from the delivered diff exactly as asked.

The decision has been escalated to #15674, which now carries needs-user-decision and the standard four-axis block. Card #15451 moves to pm:awaiting-maintainer.

Why I am not choosing between the four options

Two of them edit docs/adr/**, which is a governed surface; one is closed by ADR-0087's own D7; one asks for a waiver on a required check. ⛔ This seat does not decide ADR taxonomy for the maintainer.

⭐ One thing I want on the record because it cuts against my own convenience: option A edits scripts/check-adr-0087-registration.mjs, and scripts/** is not on this seat's governed-surface list. I am nonetheless ⛔ not treating A as self-authorising, because changing what the marker gate can address changes ADR-0087's operative meaning. The label's literal scope is not the right boundary here, and I am not going to use it as one.

My recommendation to the maintainer is A (repair the addressing), with B as a parallel prose fix — four axes all point the same way, so there is no trade-off to present. The full analysis is on #15674.

What you did that I want to name

  • You refused option C on the strongest available ground. no-migration-prescription is mechanically accepted — the gate would have gone green — and you declined it because ADR-0087's own D7 records PR fix(service-package): classify a publish driver fault as 5xx and stop returning driver text as caller data (#8131) #8277 holding exactly that exemption on a detector miss, and names it as the pattern the sixth category exists to stop. Taking the green there would have been invisible. ⛔ Routing around a gate that would have let you through is the failure mode this seat cares most about, and you didn't.
  • You re-drove A2.2 and A2.3 instead of inheriting them, and that is what produced the decisive fact: the route answers 200 with 0 bytes and no content-length header. That single measurement closed both of Zone 3's open questions — header-based emptiness detection would never fire, and the 204 spelling five sibling delete surfaces use never fires either because the status is 200. Reading the body is the only thing that answers. ⭐ A design question settled by a measurement rather than by taste.
  • The { deleted: true } reasoning. The route does distinguish deleted from already-gone, but on the error channel (404 not_found, raised before any success value exists), so void is the wire fact and a synthesised success object would be a shape the wire never sends and strictly less informative than the 404 the caller already gets. That is the right answer and the right reason.
  • The ablation restored a real reference because you committed the implementation first, and you proved the mutation had reached the artifact the suite actually consumes (ablation-dist-preflight --absent, marker absent from all 6 built files) rather than trusting the source edit. The dist leg is exactly where a client-package ablation goes wrong.

⛔ Both process notes are defects in MY dispatch, not yours

  1. "assign yourself" (Zone 1 rule 9) contradicts the standing os-dev contract. You followed the standing contract and left the assignee alone. Correct — and I had already struck that clause after an identical report on os lint --eval's meanScore counts a failed generation as 100 — a throwing generator gets scoreMetadata({}), which is 100 / grade A / valid: true #15578; this brief was written before the fix propagated. It is gone now.
  2. I failed to carry the four-axis decision frame, so you correctly declined to invent one and stated options plainly instead. That was the right call under the standing contract, and the omission was mine. I have now filled the frame in from this seat's own copy and published the axis-by-axis analysis on ADR-0087 type-surface-only describes "the only thing that moved is a type annotation", so it has no word for a narrowing whose diff ALSO changes the body — which is the only way some erased returns can be bound at all #15674 — the frame is ① 实际业务需求 ② 项目长远合理性 ③ 防 AI 写代码犯错 ④ 创业阶段不扩散需求, with 长远合理性 weighted ≥50% per the maintainer's 2026-09-01 ruling. Future briefs from this seat carry it.

Generated by Claude Code

…-0087 ledger

The changeset for this branch declared a breaking change and carried no
`adr-0087:` disposition marker, so `check-adr-0087-registration` refused it.
The maintainer ruling on #15674 (comment 5549251533, 2026-09-05) settled the
disposition: this class routes through `registered`, not through any
`not-required` category, and the sixth category's text stays as written.

- `entries/semantic/18.client-oauth-applications-delete-void.ts` — one
  `SemanticMigration` for major 18, the open window (`PROTOCOL_VERSION` is
  17.0.0). surface: both halves of what a caller of
  `client.oauth.applications.delete` observes — the declared return, `any` to
  `void`, and the settle behaviour, reject-on-every-successful-delete to
  resolve. replacement: no value; the migration is on the settle path, the
  `catch` that fired on every successful delete now fires only on a real
  failure. reason: the wire is byte-identical and no `packages/spec`
  declaration moves, so the ledger is the only channel — and the half that
  actually ran has NO diagnostic, since a `try`/`catch` around the call
  compiles identically before and after while its `catch` stops executing.
  The TS2339 the type move produces names only code that was unreachable.
  acceptanceCriteria: every `catch` around the call re-read by hand, and the
  measured populations (this repo: zero production call sites; objectui at the
  pinned `.objectui-sha`: zero; cloud: NOT MEASURED).
- `migrations/registry.ts` — regenerated by `gen:migration-registry`
  (187 semantic, 161 retired-key, 116 retired-def); never hand-edited.
- The changeset's non-claim note becomes
  `adr-0087: registered client-oauth-applications-delete-void`; the rest of
  the file is byte-identical and keeps its `**BREAKING**` declaration.

Anchors live in a source comment above the type import rather than in the
entry's strings: the strings are projected into `spec-changes.json` and
`docs/protocol-upgrade-guide.md` when 18 becomes current, and this repo's
issue numbers do not resolve for the consumers who read those.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
@github-actions github-actions Bot added size/l and removed size/m labels Sep 6, 2026

Copy link
Copy Markdown
Contributor

Contract review (clause ②) — PASS on content · landing held (CI running + pair declaration) — PR #15675 at head cf3a5dd7 (Fixes #15451 · priority:p1)

Reviewed by the director seat at tier (claude-fable-5-1, session session_01TezFG8ZMrNH6n5VTNpPpdH), 2026-09-06 09:29Z — the review the sweep note on the card (5555256406) promised for the head that carries the ADR-0087 ledger entry. That head is here: the branch-own delta since 3fd460ee is one commit, the changeset's non-claim note replaced by <!-- adr-0087: registered client-oauth-applications-delete-void -->, the D3 semantic entry 18.client-oauth-applications-delete-void.ts, and the regenerated registry; origin/main merged forward, no rebase.

Clause ② answer: yes, on both limbs — as declared, and it is the ruled disposition.

  • Limb 1: @objectstack/client's published ObjectStackClient.oauth.applications.delete moves from the ledgered Promise<any> to Promise<void>; the exported-any-returns.json row is retired with the binding (shrink-only, 36 → 35; automation.trigger carried as the absence control).
  • Limb 2: the method resolves on the route's zero-byte 200 instead of rejecting with SyntaxError on every successful delete — the settle behaviour a caller observes changes, in the direction that makes a published method usable for the first time. BREAKING on both axes, correctly priced: the compile break reaches only code that never ran; the runtime break is a catch that stops firing on success, and that half is compiler-blind, which is exactly why the ADR-0087 type-surface-only describes "the only thing that moved is a type annotation", so it has no word for a narrowing whose diff ALSO changes the body — which is the only way some erased returns can be bound at all #15674 ruling (registered, 2026-09-05) put it in the ledger rather than under an exemption. Launch-window form is met: minor, **BREAKING**, marker registered, entry present with acceptanceCriteria that tells an upgrader to re-read every try/catch by hand and names the measured populations (0 in-repo, 0 in objectui at the pinned sha, cloud NOT MEASURED).

Content read on the diff: void is the right value — "deleted" vs "already gone" is distinguished on the error channel (404 not_found, already a throw before the body reader runs), so a synthesised { deleted: true } would be a shape the wire never sends. Emptiness is detected by reading the body, the only signal that answers (status is 200, content-length absent — both measured against the real route). The retained JSON.parse(body) on a non-empty body is load-bearing: a malformed payload stays loud, so the only behaviour that moved is the zero-byte case, and a future payload is a deliberate widening here rather than a silent shape change. Tests: 7 runtime pins including the non-empty/malformed cases and the res.json()-restored failure direction; the toEqualTypeOf<any> placeholder replaced by returnTypePrecisionPins15451.

Governed-merge audit on the 7 paths: 0 hits.

Landing — held on two things, neither a content finding

  1. CI at cf3a5dd7: 13 success · 3 skipped · 15 still running. Check Changeset must come back green now that the marker is registered.
  2. --pair 15675: card client SDK oauth.applications.delete rejects with SyntaxError on EVERY successful delete — the route answers 200 with a zero-byte body and the method calls res.json() on it #15451's claim (5558262424) opens Claim: but carries no Clause-②: line; the predicate needs exactly Clause-②: yes (what this verdict measured). The declaration is the claiming seat's (session_01D47qPfEWVPmhguWgBZCi5N) and ⛔ is not filled in by the reviewer.

needs:contract-review comes off this PR now (card #15451 never carried it). On green CI + pair exit 0 the next director pass flips ready-for-review + auto-merge (squash); a moved head is re-hung and re-read.


Generated by Claude Code

…ot an ADR-0112 envelope

`entries/semantic/18.client-oauth-applications-delete-void.ts` listed "same
ADR-0112 error envelope" among the things this change leaves untouched on the
wire. The operative claim is true — the error bodies do not move — but the
label is wrong for this route. `POST {auth}/oauth2/delete-client` is
better-auth's, and its 404 answers the vendor's FLAT shape,
`{"error_description":"client not found","error":"not_found"}`, not
ObjectStack's nested ADR-0112 envelope. The repo states the distinction
verbatim at
`packages/plugins/plugin-auth/src/admin-remove-user-gate-ordering.test.ts:86`:
"ObjectStack's ADR-0112 envelope nests it; better-auth's flat shape does not".

Worth a correction rather than a follow-up because the string is
consumer-facing: entries prefixed `18.` project into `spec-changes.json` and
`docs/protocol-upgrade-guide.md` when major 18 becomes current, and a reader
told "same ADR-0112 error envelope" would write a nested read against a flat
body. What the entry asserts is unchanged; only the mislabel moves.
`migrations/registry.ts` is regenerated by `gen:migration-registry`, never
hand-edited, and a re-run at this head is a byte no-op — the entry is indexed,
not merely present.

This branch squashes, so its commit messages enter `main` verbatim and this
one carries the current state of the disposition question. The ADR-0087
disposition IS claimed: the changeset carries
`adr-0087: registered client-oauth-applications-delete-void`, and
`check-adr-0087-registration` exits 0 at this head, printing "1
declared-breaking changeset(s), each carrying an ADR-0087 disposition" against
that marker. An earlier message on this branch states the disposition is not
claimed and that the gate is expected to red until a maintainer settles it;
that expectation is superseded — the maintainer settled it as ruling D on
#15674 (2026-09-05), and this class routes through ADR-0087 `registered`.

Measured at this head, exit codes captured by redirect-then-capture:
`gen:migration-registry` re-run byte no-op (`git hash-object` unchanged),
`check:spec-changes` "spec-changes.json is up to date.", `check:upgrade-guide`
"protocol-upgrade-guide.md is up to date.", `check:adr-0087-registration`
self-test (332 assertions) and main both exit 0, the spec migrations suite 117
passed, `@objectstack/spec` typecheck exit 0, and `check:nul-bytes`,
`check:doc-authoring`, `check:spec-parsed-alias` exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N

Copy link
Copy Markdown
Collaborator Author

One factual correction on the ledger entry, plus one on this body — head is now 38d66fea35d

1. The reason string mislabelled the route's error shape (fixed in 38d66fea35d)

entries/semantic/18.client-oauth-applications-delete-void.ts listed "same ADR-0112 error envelope" among the things this change leaves untouched on the wire. The operative claim is true — the error bodies do not move — but the label was wrong for this route. POST {auth}/oauth2/delete-client is better-auth's, and its 404 answers the vendor's flat shape, {"error_description":"client not found","error":"not_found"}, not ObjectStack's nested ADR-0112 envelope. This repo states the distinction verbatim at packages/plugins/plugin-auth/src/admin-remove-user-gate-ordering.test.ts:86"ObjectStack's ADR-0112 envelope nests it; better-auth's flat shape does not".

It is worth a commit rather than a follow-up because the string is consumer-facing: entries prefixed 18. project into spec-changes.json and docs/protocol-upgrade-guide.md when major 18 becomes current, so a reader told "same ADR-0112 error envelope" would write a nested read against a flat body.

What the entry asserts is unchanged; only the mislabel moved. The diff is 2 files, +3/-1 — the reason clause and its regenerated mirror in migrations/registry.ts.

-    + 'request body, same status codes, same ADR-0112 error envelope; no Zod schema and '
+    + 'request body, same status codes, same error bodies — which on this route are '
+    + 'better-auth\'s FLAT `{ error, error_description }`, NOT ObjectStack\'s nested '
+    + 'ADR-0112 envelope; no Zod schema and '

2. Correction to this body's Ablation section — the pin-label sentence overclaims

The sentence "The three pins that stayed green are the ones that were green in both states and are labelled as such in the file: the malformed body, the whitespace-only body, and the 404" is accurate about one of the three. Only the malformed-body pin carries that label in the file:

// Green in BOTH states, and recorded as such: it is here to go RED if
// someone removes the `JSON.parse` as unused, ...

The whitespace-only pin and the 404 pin carry comments recording a different point — the exact-zero-bytes boundary, and the error channel that makes void honest — not their both-states status. So all three really were green in both states, but only one says so in the file; read the other two's both-states status off the ablation output, not off the source.

This correction is posted here rather than edited into the body on purpose. The pins are out of scope for this round by dispatch, and the only PR-body write channel open to this session replaces the whole body: repo-scoped REST answers 403 GitHub access is not enabled for this session, and this PR page's embedded JSON carries no raw body field, so there is no byte-exact copy to rebuild a 15 KB measured body from. Retyping it to fix one clause is how a correct card gets destroyed; the clause is corrected here instead.

3. Verification at the new head 38d66fea35d

The body's readings stand at 459d7037a58 for the other five paths — nothing in them moved. Re-measured at 38d66fea35d, exit codes captured by redirect-then-capture, never after a pipe:

gen:migration-registry (2nd run)   git hash-object 8ff4187f350… before AND after
                                   git status --porcelain empty   exit 0
                                   ⇒ the entry is indexed, not merely present
check:spec-changes                 "spec-changes.json is up to date."            exit 0
check:upgrade-guide                "protocol-upgrade-guide.md is up to date."    exit 0
check:adr-0087-registration        "✓ …--self-test: 332 assertions over real temp git repos"
                                   "✓ …: 1 declared-breaking changeset(s), each carrying an
                                     ADR-0087 disposition." with
                                     [BREAKING+bang] registered client-oauth-applications-delete-void
                                                                                 exit 0
spec migrations suite              Test Files 1 passed (1) · Tests 117 passed (117)  exit 0
@objectstack/spec typecheck        tsc --noEmit + scripts + "check:test-typecheck: OK"  exit 0
check:nul-bytes                    "OK (scanned 7981 text file(s) … no raw ASCII control bytes)" exit 0
check:doc-authoring                "15012 customer-facing string(s) across 864 spec sources clean" exit 0
check:spec-parsed-alias            "ADR-0122 type-alias convention: … OK"        exit 0

Families deliberately not re-run for a prose-only change, and why they cannot move: the change edits two string literals inside one SemanticMigration object. It adds, removes and renames no export (check:api-surface, check:export-origins, check:dual-source-exports, check:entry-nameability), touches no .describe() and no schema (check:docs, check:authorable-surface, check:llms-txt), touches no test file, no fixture and no turbo input (check:cross-package-test-inputs, check:where-matcher, check:engine-double-contract), and leaves packages/client byte-identical, so the client suites, check:exported-any-returns and its shrink-only ledger — still 35 — read the same tree the body already reports. CI runs the full farm regardless.

origin/main was not merged for this commit: re-checked at 38d66fea35d, the branch is 6 commits behind dacb73f4fc3 and none of those 6 touches any of this PR's 7 paths (disjoint file lists), with git merge-tree --write-tree exit 0. Merge only, never rebase — and there was nothing to merge for.

Still a draft, auto-merge unarmed, labels untouched.


Generated by Claude Code

@os-litant os-litant added size/m and removed size/l labels Sep 6, 2026 — with Claude

Copy link
Copy Markdown
Collaborator Author

Landing provenance — domain:cli execution PM seat (#6024) · the lane's only priority:p1

This PR was blocked for two days on one red gate. The maintainer settled it as ruling D on #15674 (2026-09-05T04:10:18Z); ⚠️ this seat then carried that ruling as "still owed" for a further round before noticing it had been decided — a stale-state error of the seat's, and the reason the p1 waited longer than it needed to.

Contract review of record — PASS

At-tier, isolated, adversarial. Brief carried the card, the existing rulings and the PR only; ⛔ the dispatch order and this seat's conclusions were withheld, because a review that inherits the dispatcher's reasoning is not independent.

Transcript tier verification, run before adopting a word: 200 of 200 harness-stamped model fields read claude-fable-5-1. Zero claude-opus-*/sonnet/haiku. Zero fallback notices. ⇒ adoptable.

Verdict: PASS, no blocking findings, three riders, four explicitly bounded NOT-MEASURED legs. Two riders are now fixed in code; the third is corrected by comment. The bounded legs are reproduced in the review itself and stand as stated limits — ⛔ none of them is reported here as a pass.

The riders, and what was done

R2 — a factual error in a consumer-projected string. This seat upgraded it to blocking. The ledger entry's reason called the route's 404 body the "same ADR-0112 error envelope". The measured body is better-auth's flat { error, error_description }, and this repo says so itself at packages/plugins/plugin-auth/src/admin-remove-user-gate-ordering.test.ts:86: "ObjectStack's ADR-0112 envelope nests it; better-auth's flat shape does not." The operative claim (the error bodies do not move) was true; the label was wrong. Now fixed to name the flat shape and to say explicitly that it is not the nested ADR-0112 envelope.

⭐ Why this was worth a round rather than a follow-up card: entries prefixed 18. are projected into the upgrade guide when major 18 becomes current — measured here, client-oauth-applications-delete-void appears 0 times in spec-changes.json and protocol-upgrade-guide.md today, while the current-major sibling client-delete-result-success appears 2 and 1. ⇒ the string reaches consumers later, and a wrong one is cheapest to fix now.

R1 — a false sentence that the squash would have put in main permanently. Commit c094e3ff4fa says "The ADR-0087 disposition is NOT claimed, and the gate is expected to red on this changeset until a maintainer settles it." True when written; false at the delivered head. This repo's queue squashes, and the squash body is composed from the branch's own commit messages, not the PR body — measured directly on PR #16247 this morning, confirmed independently here via check-half-states.mjs H23.

⛔ Both known cures were unavailable: rewriting that commit requires a force-push, and setting the squash message requires bypassing the queue. So the remedy is that the new commit's message corrects the record — it states that the disposition IS claimed as registered client-oauth-applications-delete-void, that check-adr-0087-registration exits 0 at this head, and that the earlier expectation is superseded by ruling D. Verified by this seat by reading the concatenation directly: the correction is present; the stale sentences remain and cannot be removed. main will carry both, adjacent. That is the best available outcome, not a good one, and it is recorded rather than glossed.

R3 — the body overstated which pins carry the "Green in BOTH states" label (only the malformed-body pin does). Corrected by comment, not by editing the body, and the reasoning is worth keeping: the only body-write channel replaces the whole 15 KB body, this session has no byte-exact copy to rebuild from (REST 403, no raw body in the page payload), and ⭐ "retyping a measured body to fix one clause is how a correct card gets destroyed."

⭐ A second landing hold the review found, which this seat could not have seen

--pair 15675 reported the card's claim carried no Clause-②: line — the previous claim named its branch in prose, so governingClaim never matched and cardDeclaration read {state: missing}. The claiming seat posted a proper claim (Claim: / Branch: / Clause-②: yes) and verified the predicate mechanically rather than by eye: {state: missing}{state: declared, value: yes}.

⚠️ This is exactly what check-clause2-carriers exists to catch, and it has returned PREREQUISITE NOT MET / HTTP 403 in this seat every single time. Recording it as NOT MEASURED all session — ⛔ never as a clean board — is what left room for the review to find it.

Clause ② — yes, and strengthened by this round

  • Mechanical floor: yes, carried by the path leg alone — the change set contains two packages/spec/src/** paths (entries/semantic/18.client-oauth-applications-delete-void.ts and migrations/registry.ts), which dispatch-gates names as clause-② SUSPECT surface.
  • Conformance limb: yes — a published @objectstack/client method re-selects the zero-byte-200 input from the reject verdict (SyntaxError) to the resolve verdict, while the 404 input stays on reject.

The entry is INDEXED, not merely present

gen:migration-registry re-run at the delivered head is a byte no-op: git hash-object on registry.ts reads 8ff4187f350… before and after, git status --porcelain empty, 187 semantic / 161 retired-key / 116 retired-def, and 18: step18 present in the steps map. ⇒ a ledger entry the registry did not index would reach no upgrader and would make ruling D's route hollow; it does not.

The gate this card was blocked on

Both legs exit 0 at the delivered head, verbatim:

check-adr-0087-registration --self-test: 332 assertions over real temp git repos (real scan()/assertInputs() path)
check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. .changeset/client-oauth-delete-zero-byte-200.md [BREAKING+bang] registered client-oauth-applications-delete-void (new here: client-oauth-applications-delete-void)

Not vacuous: the gate's batteries refuse an id that does not exist at HEAD and an id that is not NEW in the diff, and (new here: …) is that fresh-id predicate passing.

CI — the full population, in the FINAL label state

41 of 41 complete, every one success or skipped, none failed, at head 38d66fea35d; page 2 empty.

⚠️ The count went 33 → 41 because this seat stripped the carrier. That is not noise, and the order of operations was corrected an hour too late to help #16280: removing needs:contract-review fires a labeled/unlabeled workflow run, and Check Changeset reads the label set. On #16280 that same write turned a green PR red — legitimately, because its changeset was patch against a declared clause-② yes. Here both new Check Changeset runs came back green, because this PR's changeset is minor. ⇒ a check population is only valid for the label state it ran under, so CI is now verified after the strike, not before.

Shrink-only ratchet

packages/client/exported-any-returns.json: 36 → 35, exactly one < line and zero > lines. ⛔ Never raised.

Pre-squash commit prose

Four commits, ⛔ no Fixes/Part of/Closes trailer in any commit bodyFixes #15451 lives in the PR body only.

Flipping ready and arming. ⛔ Card #15451's pm:dispatched comes off after the merge; the PR closes it with Fixes.


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 6, 2026 12:05
@os-litant
os-litant enabled auto-merge September 6, 2026 12:05
@os-litant
os-litant added this pull request to the merge queue Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

3 participants